hetzner nixos

2026-01-17 · 4 min read

Here's roughly how to setup a NixOS dev server on a cost-effective Hetzner bare metal machine.

We'll use:

  • Hetzner Robot to rent a bare metal machine
  • A flakes-less NixOS setup, configured for a headless dev server.
  • nixos-anywhere to install our NixOS config onto the machine.
  • disko to declaratively partition our disks, setup RAID, and manage our filesystems.

Ordering #

Order a cheap auction server on Hetzner Robot:

Paste in your ssh pubkey during the order process.

Wait for a confirmation email.

Set server name #

In https://robot.hetzner.com/server, set the server name to e.g. omnara1.phlip9.com. Don't forget to also grab the server IPv4 and IPv6 addresses.

DNS (Optional) #

Add A/AAAA records for the server in your DNS provider:

$ORIGIN phlip9.com  ; zone
$TTL 600            ; default TTL

omnara1  IN  A      95.217.195.225
omnara1  IN  AAAA   2a01:4f9:4a:52de::2

SSH into the machine #

$ ssh root@omnara1.phlip9.com
Linux rescue 6.12.19 #1 SMP Tue Oct 14 13:09:18 UTC 2025 x86_64

-------------------------------------------------------------------------------------------------------------------------

  Welcome to the Hetzner Rescue System.

  This Rescue System is based on Debian GNU/Linux 12 (bookworm) with a custom kernel.
  You can install software like you would in a normal system.

  To install a new operating system from one of our prebuilt images, run 'installimage' and follow the instructions.

  Important note: Any data that was not written to the disks will be lost during a reboot.

  For additional information, check the following resources:
    Rescue System:           https://docs.hetzner.com/robot/dedicated-server/troubleshooting/hetzner-rescue-system
    Installimage:            https://docs.hetzner.com/robot/dedicated-server/operating-systems/installimage
    Install custom software: https://docs.hetzner.com/robot/dedicated-server/operating-systems/installing-custom-images
    other articles:          https://docs.hetzner.com/robot

-------------------------------------------------------------------------------------------------------------------------

Rescue System (via Legacy/CSM) up since 2026-01-17 12:01 +01:00

Hardware data:

   CPU1: Intel(R) Xeon(R) E-2176G CPU @ 3.70GHz (Cores 12)
   Memory:  64179 MB (ECC)
   Disk /dev/nvme0n1: 960 GB (=> 894 GiB) doesn't contain a valid partition table
   Disk /dev/nvme1n1: 960 GB (=> 894 GiB) doesn't contain a valid partition table
   Total capacity 1788 GiB with 2 Disks

Network data:
   eth0  LINK: yes
         MAC:  a8:5e:45:3d:aa:33
         IP:   95.217.195.225
         IPv6: 2a01:4f9:4a:52de::2/64
         Intel(R) PRO/1000 Network Driver

NixOS configs #

Here's the relevant parts of the NixOS config for bootstrapping on Hetzner. You can see the complete config in phlip9/dotfiles.

First, a simplified top-level default.nix:

# default.nix

{ 
  nixpkgs ? <nixpkgs>,
  pkgs ? import nixpkgs {},
}:

{
  inherit pkgs;

  # NixOS system configs
  nixosConfigs = import ./nixos { inherit nixpkgs; };
}

All the top-level NixOS machine config declarations are in nixos/default.nix:

# nixos/default.nix

{ nixpkgs }:

let
  # be careful not to pass `pkgs` eval to normal NixOS system evals. they should
  # control their own package set.
  nixosSystem =
    args:
    import (nixpkgs + "/nixos/lib/eval-config.nix") (
      {
        lib = import (nixpkgs + "/lib");
        system = null;
        modules = args.modules;
      }
      // (builtins.removeAttrs args [ "modules" ])
    );
in
{
  # omnara1 - Hetzner bare metal dev server
  omnara1 = nixosSystem {
    modules = [ ./omnara1/default.nix ];
  };
}

The omnara1 machine NixOS config:

# nixos/omnara1/default.nix

# omnara1 - Hetzner bare metal dev server
#
# Hardware:
# - 2x 894 GiB NVMe SSDs in RAID 0
# - IPv4: 95.217.195.225
# - IPv6: 2a01:4f9:4a:52de::2
{
  lib,
  pkgs,
  ...
}:
{
  imports = [
    # use minimal+headless (no GUI/window manager) for server images
    (modulesPath + "/profiles/minimal.nix")
    (modulesPath + "/profiles/headless.nix")

    # hardware-specific configs
    ./hardware.nix

    # disk partitions and filesystems
    ./disko-config.nix
  ];

  # Don't update this unless you know what you're doing.
  system.stateVersion = "26.05";

  # Locale
  time.timeZone = "UTC";
  i18n.defaultLocale = "en_US.UTF-8";

  # Bootloader
  # My Hetzner machine seemed to need a MBR + grub + EFI support to boot.
  boot.loader = {
    grub = {
      enable = true;
      efiSupport = true;
      efiInstallAsRemovable = true;
    };
    timeout = 0;
  };

  # Use latest kernel
  boot.kernelPackages = pkgs.linuxPackages_latest;

  # Silence mdadm warning about missing MAILADDR or PROGRAM
  boot.swraid.mdadmConf = "PROGRAM ${pkgs.coreutils}/bin/true";

  # Hostname
  networking.hostName = "omnara1";
  networking.domain = "phlip9.com";

  # Use systemd networkd
  systemd.network.enable = true;
  networking.useNetworkd = true;

  # Static network configuration (Hetzner)
  networking.wireless.enable = false;
  networking.useDHCP = false;
  networking.interfaces.eno1 = {
    ipv4.addresses = [
      {
        address = "95.217.195.225";
        prefixLength = 26;
      }
    ];
    ipv6.addresses = [
      {
        address = "2a01:4f9:4a:52de::2";
        prefixLength = 64;
      }
    ];
  };
  networking.defaultGateway = {
    address = "95.217.195.193";
    interface = "eno1";
  };
  networking.defaultGateway6 = {
    address = "fe80::1";
    interface = "eno1";
  };
  networking.nameservers = [
    "185.12.64.1"
    "185.12.64.2"
    "2a01:4ff:ff00::add:1"
    "2a01:4ff:ff00::add:2"
  ];
  services.timesyncd.servers = [
    "ntp1.hetzner.de"
    "ntp2.hetzner.com"
    "ntp3.hetzner.net"
  ];

  # Firewall
  networking.firewall.enable = true;

  # Make users and groups static and only configurable via nix
  users.mutableUsers = false;

  # My user
  users.users.phlip9 = {
    isNormalUser = true;
    description = "Philip Kannegaard Hayes";
    extraGroups = [ "wheel" ];
    openssh.authorizedKeys.keys = [
      "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIP/8j7BMuNsn+aXTjm3LP8mDR8q/GylbrkGVBn1PBrwhAAAABHNzaDo= phlip9-5ci-fips"
    ];
  };

  # sudo-rs - memory-safe sudo
  security.sudo-rs = {
    enable = true;
    execWheelOnly = true;
  };

  # password-less sudo for wheel group
  security.sudo-rs.wheelNeedsPassword = false;

  # `nix` settings
  nix.settings = {
    # Allow all sudoers to manage NixOS system.
    trusted-users = [
      "root"
      "@wheel"
    ];

    # Enable `nix` command and flakes support
    experimental-features = [
      "nix-command"
      "flakes"
    ];
  };

  # Basic packages
  environment.systemPackages = [ pkgs.git ];

  # OpenSSH
  # SSH server on non-standard port 22022 w/ some hardened settings.
  # Use non-standard port to silence ssh bots...
  services.openssh = {
    enable = true;
    ports = [ 22022 ];
    openFirewall = true;

    # Only generate an ed25519 key and not an RSA key.
    # This is just the default value w/o the type=rsa entry.
    hostKeys = [
      {
        type = "ed25519";
        path = "/etc/ssh/ssh_host_ed25519_key";
      }
    ];

    # Harden the openssh server
    settings = {
      PasswordAuthentication = false;
      PermitRootLogin = lib.mkForce "no";
      KbdInteractiveAuthentication = false;
      X11Forwarding = false;

      # Only allow a single set of crypto primitives.
      KexAlgorithms = [
        "curve25519-sha256"
        "curve25519-sha256@libssh.org"
      ];
      Macs = [ "hmac-sha2-256-etm@openssh.com" ];
      Ciphers = [ "aes128-gcm@openssh.com" ];
    };
  };
}

Disk partition and filesystem layout via nix-community/disko.

For this machine I'm using RAID 0, i.e., combine all available disk storage space into one virtual disk. This is normally not recommended, but more space is more important than safety/reliablility/redundancy for my specific use case. None of the data on this machine is important. I can blow it away and rebuild it in a few minutes.

Normally you should use RAID 1 so Hetzner ops can safely hot-swap disks when one goes bad.

# nixos/omnara1/disko-config.nix

# mdadm RAID 0 across two NVMe SSDs (~1.8 TiB unified storage)
#
# Hardware:
# - /dev/nvme0n1 - 894.25 GiB (Toshiba KXD51RUE960G)
# - /dev/nvme1n1 - 894.25 GiB (Toshiba KXD51RUE960G)
{
  disko = {
    enableConfig = true;

    devices = {
      disk = {
        nvme0 = {
          type = "disk";
          device = "/dev/nvme0n1";
          content = {
            type = "gpt";
            partitions = {
              boot = {
                size = "1M";
                type = "EF02"; # for grub MBR
              };
              ESP = {
                size = "2G";
                type = "EF00";
                content = {
                  type = "filesystem";
                  format = "vfat";
                  mountpoint = "/boot";
                  mountOptions = [ "umask=0077" ];
                };
              };
              swap = {
                size = "32G";
                content = {
                  type = "swap";
                };
              };
              mdadm = {
                size = "100%";
                content = {
                  type = "mdraid";
                  name = "nixos";
                };
              };
            };
          };
        };
        nvme1 = {
          type = "disk";
          device = "/dev/nvme1n1";
          content = {
            type = "gpt";
            partitions = {
              mdadm = {
                size = "100%";
                content = {
                  type = "mdraid";
                  name = "nixos";
                };
              };
            };
          };
        };
      };
      mdadm = {
        nixos = {
          type = "mdadm";
          level = 0;
          content = {
            type = "filesystem";
            format = "ext4";
            mountpoint = "/";
          };
        };
      };
    };
  };
}

And an initial hardware.nix for bootstrapping:

{
  nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}

Copy/update networking config in the NixOS config above #

Set your ssh pubkey in the user openssh.authorizedKeys.keys.

Set networking.hostName and networking.domain (ex: "omnara1" and "phlip9.com" respectively in this case).

Set the NIC IPs and gateway addresses. Hetzner recommends using a static network configuration (i.e., no DHCP) for stability, especially if you want functioning IPv6.

# See the IPv4 gateway (IPv6 just uses a link-local address fe80::1)
root@omnara1.phlip9.com:~$ ip route list
default via 95.217.195.193 dev eth0 proto static
95.217.195.192/26 dev eth0 proto kernel scope link src 95.217.195.225

NOTE: this NixOS config uses systemd-networkd. In my case, this renamed the interface from eth0 to eno1 between the rescue image and the NixOS boot. Maybe usePredictableInterfaceNames = false would disable this?

The relevant items are:

  • ipv4 CIDR: 95.217.195.225/26
  • ipv6 CIDR: 2a01:4f9:4a:52de::2/64
  • ipv4 gateway: 95.217.195.193
  • ipv6 gateway: fe80::1

Depending on whether the machine uses NVMe/SSD/HD disks, you may also need to change the device names in disko-config.nix (ex: "/dev/nvme0n1").

Deploy initial NixOS config #

In my dotfiles repo, I run this justfile recipe:

$ just deploy-omnara1-nixos-anywhere
deploy-omnara1-nixos-anywhere:
    #!/usr/bin/env bash
    set -euxo pipefail

    IFS=$'\n' paths=($(nix build -f . --print-out-paths --no-link \
        pkgs.nixos-anywhere \
        nixosConfigs.omnara1.config.system.build.diskoScript \
        nixosConfigs.omnara1.config.system.build.toplevel))

    nixos_anywhere=${paths[0]}
    disko_script=${paths[1]}
    toplevel=${paths[2]}

    "$nixos_anywhere/bin/nixos-anywhere" -L \
        --store-paths "$disko_script" "$toplevel" \
        root@omnara1.phlip9.com

If anything goes wrong, just reset the server into rescue mode and try again.

To reset into rescue mode:

  1. Find the server in https://robot.hetzner.com/server.
  2. Go under "Rescue" and select "OS=Linux" "Public Key=phlip9-5ci-fips". Then hit "Activate rescue system".
  3. Go under "Reset", select "Execute an automatic hardware reset" and hit "Send".
  4. Wait a minute for the server to reboot into rescue mode.

SSH into the deployed server #

$ ssh phlip9@omnara1.phlip9.com -p 22022
phlip9@omnara1:~$

As a dev server, this machine manages its own configuration. I update the configuration and switch to the new generation on the machine itself.

If you want remote deploys, consider serokell/deploy-rs or similar.

Init home-manager config (Optional) #

If you use home-manager (and have it managed separately from the NixOS config like me), you can set that up now:

phlip9@omnara1:~$ mkdir dev
phlip9@omnara1:~/dev$ cd dev
phlip9@omnara1:~/dev$ git clone git@github.com:phlip9/dotfiles.git
phlip9@omnara1:~/dev/dotfiles$ cd dotfiles

# Setup home-manager
phlip9@omnara1:~/dev/dotfiles$ ./bin/hms

# Exit and ssh back in
phlip9@omnara1:~$ ^D
$ ssh phlip9@omnara1.phlip9.com -p 22022

Generate hardware.nix #

Generate the hardware-specific nix configuration. This enables some non-default kernel modules that are only available on your specific hardware.

phlip9@omnara1:~$ cd ~/dev/dotfiles
phlip9@omnara1:~/dev/dotfiles$ nixos-generate-config --show-hardware-config \
    | tee > nixos/omnara1/hardware.nix
# Do not modify this file!  It was generated by ‘nixos-generate-config’
# and may be overwritten by future invocations.  Please make changes
# to /etc/nixos/configuration.nix instead.
{ config, lib, pkgs, modulesPath, ... }:

{
  imports =
    [ (modulesPath + "/installer/scan/not-detected.nix")
    ];

  boot.initrd.availableKernelModules = [ "xhci_pci" "ahci" "nvme" ];
  boot.initrd.kernelModules = [ ];
  boot.kernelModules = [ "kvm-intel" ];
  boot.extraModulePackages = [ ];

  fileSystems."/" =
    { device = "/dev/disk/by-uuid/06f1bb3f-2a1f-4a38-8b23-a7e0a006985f";
      fsType = "ext4";
    };

  fileSystems."/boot" =
    { device = "/dev/disk/by-uuid/CF71-A6CD";
      fsType = "vfat";
      options = [ "fmask=0077" "dmask=0077" ];
    };

  swapDevices =
    [ { device = "/dev/disk/by-uuid/053ebff4-b1c6-4976-8baa-0863d45f5db0"; }
    ];

  nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
  hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
  boot.swraid.enable = true;
}

Since I'm using disko to manage disks/partitions/filesystems, I dropped all the fileSystems and swapDevices from hardware.nix.

Finally, switch to the updated config:

phlip9@omnara1:~/dev/dotfiles$ nixos-rebuild -f . -A nixosConfigs.omnara1 switch